# Copyright (c) 2012 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""Status management pages."""

import datetime
import json
import logging
import re
import urllib2

from google.appengine.api import memcache
from google.appengine.ext import db

from base_page import BasePage
from sheriff import SheriffSchedules
import codesite_utils
import utils


OPEN_STATE = 'open'
CAUTION_STATE = 'caution'
CLOSED_STATE = 'closed'

# The maximum chunk of statuses that are displayed.
MAX_STATUS_CHUNK = 1000
# The default chunk of statuses that are displayed.
DEFAULT_STATUS_CHUNK = 25

CHROMIUM_DEPS_FILE = 'http://src.chromium.org/viewvc/chrome/trunk/src/DEPS'


class Status(db.Model):
  """Description for the status table."""
  # The username who added this status.
  username = db.StringProperty(required=True)
  # The date when the status got added.
  date = db.DateTimeProperty(auto_now_add=True)
  # The message. It can contain html code.
  message = db.StringProperty(required=True)
  # The first revision submitted after the status first changed.
  first_rev = db.IntegerProperty(required=False)
  # The last revision submitted before the status changed.
  last_rev = db.IntegerProperty(required=False)

  @property
  def general_state(self):
    """Returns a string representing the state that the status message
    describes.
    """
    if re.search(CLOSED_STATE, self.message, re.IGNORECASE):
      return CLOSED_STATE
    elif re.search(CAUTION_STATE, self.message, re.IGNORECASE):
      return CAUTION_STATE
    else:
      return OPEN_STATE

  @property
  def codesite_changes_url(self):
    return codesite_utils.GetCodesiteUrlWithChangesRange(self.first_rev,
                                                         self.last_rev)

  @staticmethod
  def validate_state_message(message):
    """Throws an Error iff exactly one of closed, open or caution is missing."""
    closed_state = re.search(CLOSED_STATE, message, re.IGNORECASE)
    caution_state = re.search(CAUTION_STATE, message, re.IGNORECASE)
    open_state = re.search(OPEN_STATE, message, re.IGNORECASE)
    if (closed_state and open_state) or (
        closed_state and caution_state) or (
        caution_state and open_state):
      raise ValueError(
          'Cannot specify two keywords from (\'%s\', \'%s\', \'%s\') in a '
          'status message!' % (CLOSED_STATE, CAUTION_STATE, OPEN_STATE))
    elif not (closed_state or caution_state or open_state):
      raise ValueError(
          'Must specify either \'%s\' or \'%s\' or \'%s\' somewhere in the '
          'status message!' % (CLOSED_STATE, CAUTION_STATE, OPEN_STATE))

  @property
  def can_commit_freely(self):
    return (self.general_state == OPEN_STATE or
            self.general_state == CAUTION_STATE)

  def AsDict(self):
    data = super(Status, self).AsDict()
    data['general_state'] = self.general_state
    data['can_commit_freely'] = self.can_commit_freely
    return data


def get_status():
  """Returns the current Status, e.g. the most recent one."""
  status = memcache.get('last_status')
  if status is None:
    status = Status.all().order('-date').get()
    # Use add instead of set(); must not change it if it was already set.
    memcache.add('last_status', status)
  return status


def put_status(status):
  """Sets the current Status, e.g. append a new one."""
  # Add the last_rev to the previous status.
  prev_status = memcache.get('last_status')
  if prev_status is None:
    prev_status = Status.all().order('-date').get()
  prev_status.last_rev = codesite_utils.GetCurrLatestRevNum()
  prev_status.put()
  # Now add the new status.
  status.put()
  # Flush the cache.
  memcache.flush_all()
  memcache.set('last_status', status)
  memcache.delete('last_statuses')


def get_last_statuses(limit):
  """Returns the last |limit| statuses."""
  statuses = memcache.get('last_statuses')
  if not statuses or len(statuses) < limit:
    statuses = Status.all().order('-date').fetch(limit)
    memcache.add('last_statuses', statuses)
  return statuses[:limit]


def parse_date(date):
  """Parses a date."""
  match = re.match(r'^(\d\d\d\d)-(\d\d)-(\d\d)$', date)
  if match:
    return datetime.datetime(
        int(match.group(1)), int(match.group(2)), int(match.group(3)))
  if date.isdigit():
    return datetime.datetime.utcfromtimestamp(int(date))
  return None


class AllStatusPage(BasePage):
  """Displays a big chunk, equal to MAX_CHUNK of status values."""
  def get(self):
    query = db.Query(Status).order('-date')
    start_date = self.request.get('startTime')
    if start_date:
      query.filter('date <', parse_date(start_date))
    try:
      limit = int(self.request.get('limit'))
    except ValueError:
      limit = MAX_STATUS_CHUNK
    end_date = self.request.get('endTime')
    beyond_end_of_range_status = None
    if end_date:
      query.filter('date >=', parse_date(end_date))
      # We also need to get the very next status in the range, otherwise
      # the caller can't tell what the effective tree status was at time
      # |end_date|.
      beyond_end_of_range_status = Status.all(
          ).filter('date <', end_date).order('-date').get()

    out_format = self.request.get('format', 'csv')
    if out_format == 'csv':
      # It's not really an html page.
      self.response.headers['Content-Type'] = 'text/plain'
      template_values = self.InitializeTemplate(self.APP_NAME + ' Tree Status')
      template_values['status'] = query.fetch(limit)
      template_values['beyond_end_of_range_status'] = beyond_end_of_range_status
      self.DisplayTemplate('allstatus.html', template_values)
    elif out_format == 'json':
      self.response.headers['Content-Type'] = 'application/json'
      self.response.headers['Access-Control-Allow-Origin'] = '*'
      statuses = [s.AsDict() for s in query.fetch(limit)]
      if beyond_end_of_range_status:
        statuses.append(beyond_end_of_range_status.AsDict())
      data = json.dumps(statuses)
      callback = self.request.get('callback')
      if callback:
        if re.match(r'^[a-zA-Z$_][a-zA-Z$0-9._]*$', callback):
          data = '%s(%s);' % (callback, data)
      self.response.out.write(data)
    else:
      self.response.headers['Content-Type'] = 'text/plain'
      self.response.out.write('Invalid format')


class LkgrPage(BasePage):
  """Displays Skia's LKGR.

  Parses Chromium's DEPS file to get this information. The justification for
  this is that a Skia rev makes it into Chromium after passing all required
  tests.
  """

  def get(self):
    """Displays Skia's LKGR."""
    try:
      chromium_deps = urllib2.urlopen(CHROMIUM_DEPS_FILE)
      for line in chromium_deps.readlines():
        if 'skia_revision' in line:
          skia_lkgr = re.match('.*skia_revision.*?([0-9]+).*', line).group(1)
          break
      else:
        raise Exception('Could not find skia_revision!')
    except Exception, e:
      skia_lkgr = -1
      logging.error(e)
    self.response.out.write(skia_lkgr)


class GitLkgrPage(BasePage):
  """Displays Skia's Git LKGR.

  Parses Chromium's DEPS file to get this information. The justification for
  this is that a Skia rev makes it into Chromium after passing all required
  tests.
  """

  def get(self):
    """Displays Skia's Git LKGR."""
    try:
      chromium_deps = urllib2.urlopen(CHROMIUM_DEPS_FILE)
      for line in chromium_deps.readlines():
        # Put in quotes to make regex matching simpler.
        line = line.replace('&quot;', '"')
        if 'skia_hash' in line:
          skia_git_lkgr = re.match('.*skia_hash.*?([0-9,a-z]+).*',
                                   line).group(1)
          break
      else:
        raise Exception('Could not find skia_hash!')
    except Exception, e:
      skia_git_lkgr = -1
      logging.error(e)
    self.response.out.write(skia_git_lkgr)


class BannerStatusPage(BasePage):
  """Displays the /current page."""

  def get(self):
    """Displays the current message and nothing else."""
    out_format = self.request.get('format', 'html')
    status = get_status()
    if out_format == 'raw':
      self.response.headers['Content-Type'] = 'text/plain'
      self.response.out.write(status.message)
    elif out_format == 'json':
      self.response.headers['Content-Type'] = 'application/json'
      if self.request.get('with_credentials'):
        self.response.headers['Access-Control-Allow-Origin'] = (
            'gerrit-int.chromium.org, gerrit.chromium.org')
        self.response.headers['Access-Control-Allow-Credentials'] = 'true'
      else:
        self.response.headers['Access-Control-Allow-Origin'] = '*'
      data = json.dumps(status.AsDict())
      callback = self.request.get('callback')
      if callback:
        if re.match(r'^[a-zA-Z$_][a-zA-Z$0-9._]*$', callback):
          data = '%s(%s);' % (callback, data)
      self.response.out.write(data)
    elif out_format == 'html':
      template_values = self.InitializeTemplate(self.APP_NAME + ' Tree Status')
      template_values['message'] = status.message
      template_values['state'] = status.general_state
      template_values['sheriff'] = SheriffSchedules.get_current_sheriff()
      self.DisplayTemplate('current.html', template_values, use_cache=True)
    else:
      self.error(400)


class BinaryStatusPage(BasePage):
  """Displays the /binarystatus page."""

  def get(self):
    """Displays 1 if the tree is open or in caution, and 0 if it is closed."""
    status = get_status()
    self.response.headers['Cache-Control'] = 'no-cache, private, max-age=0'
    self.response.headers['Content-Type'] = 'text/plain'
    self.response.out.write(str(int(status.can_commit_freely)))

  @utils.admin_only
  def post(self):
    """Adds a new message from a backdoor.

    The main difference with MainPage.post() is that it doesn't look for
    conflicts and doesn't redirect to /.
    """
    message = self.request.get('message')
    username = self.request.get('username')
    if message and username:
      put_status(Status(message=message, username=username,
                        first_rev=codesite_utils.GetNextRevNum()))
    self.response.out.write('OK')


class MainPage(BasePage):
  """Displays the main page containing the last DEFAULT_STATUS_CHUNK msgs."""

  @utils.require_user
  def get(self):
    return self._handle()

  def _handle(self, error_message='', last_message=''):
    """Sets the information to be displayed on the main page."""
    try:
      limit = min(max(int(self.request.get('limit')), 1), MAX_STATUS_CHUNK)
    except ValueError:
      limit = DEFAULT_STATUS_CHUNK
    status = get_last_statuses(limit)
    current_status = get_status()
    if not last_message:
      last_message = current_status.message

    template_values = self.InitializeTemplate(self.APP_NAME + ' Tree Status')
    template_values['status'] = status
    template_values['message'] = last_message
    template_values['last_status_key'] = current_status.key()
    template_values['error_message'] = error_message
    template_values['default_status_chunk'] = DEFAULT_STATUS_CHUNK
    self.DisplayTemplate('main.html', template_values)

  @utils.require_user
  @utils.admin_only
  def post(self):
    """Adds a new message."""
    # We pass these variables back into get(), prepare them.
    last_message = ''
    error_message = ''

    # Get the posted information.
    new_message = self.request.get('message')
    last_status_key = self.request.get('last_status_key')
    if not new_message:
      # A submission contained no data. It's a better experience to redirect
      # in this case.
      self.redirect("/")
      return

    # Ensure the new status message contains exactly one of 'open' or 'closed'
    # or 'caution'.
    try:
      Status.validate_state_message(new_message)
    except ValueError, e:
      error_message = e.message
      return self._handle(e.message, new_message)

    current_status = get_status()
    if current_status and (last_status_key != str(current_status.key())):
      error_message = ('Message not saved, mid-air collision detected, '
                       'please resolve any conflicts and try again!')
      last_message = new_message
      return self._handle(error_message, last_message)
    else:
      put_status(Status(message=new_message, username=self.user.email(),
                        first_rev=codesite_utils.GetNextRevNum()))
      self.redirect("/")


def bootstrap():
  # Guarantee that at least one instance exists.
  if db.GqlQuery('SELECT __key__ FROM Status').get() is None:
    Status(username='none', message='welcome to status',
           first_rev=codesite_utils.GetNextRevNum()).put()
